8.2 关键字:const

const对象必须初始化

因为const对象一旦创建后其值就不能再改变,因此const对象必须初始化,其初始值可以是任意复杂的表达式:

const int i = get_size();  // 正确: 运行时初始化
const int j = 42;          // 正确: 编译时初始化
const int k;               // 错误: 未经初始化的const变量

const对象仅在文件内有效

举个例子,我们在编译时初始化一个const对象:

const int i = 10;

编译器会在编译过程把用到该变量的地方都替换为对应的值。为了执行这个替换,编译器必须知道变量的初始值,如果程序包含多个文件,那么每个用了这个const对象的文件都必须得能访问到它的初始值才行(即每个文件都要定义const对象)。为了避免对同一变量的重复定义,当多个文件中出现同名的const对象时,其实等同于在不同文件中分别定义了独立的变量。

/*
 * 下面是合法的, 不存在变量i重复定义问题
 */

// foo.cpp
const int i = 10;

// bar.cpp
const int i = 5;

如果想在多个文件之间共享const对象,那么必须在变量的定义之前添加extern关键字:

/*
 * 下面是合法的, main.cpp和foo.cpp中的const int对象是同一个
 */

// foo.cpp
extern const int i = 10;

// main.cpp
#include <iostream>

int main(void) {
    extern int i;
    std::cout << "i:" << i << std::endl;
}

允许常量引用绑定非常量对象、字面值甚至一般表达式

一般而言,引用的类型必须与其所引用对象的类型一致,但是有两个例外:

  • 初始化常量引用时允许用任意表达式作为初始值,只要该表达式的结果能转换成引用类型即可,允许为一个常量引用绑定非常量的对象、字面值甚至是一个一般表达式(如下)

  • 可以将基类的指针或引用绑定到派生类对象上(后续面向对象章节再探讨)

int i = 10;

const int &ri1 = i;      // 合法: 绑定到非常量对象
const int &ri2 = 100;    // 合法: 绑定到字面值
const int &ri3 = 1 + 1;  // 合法: 绑定到一般表达式

顶层const与底层const

指针本身是一个对象,因此指针本身是不是常量与指针所指对象是不是常量是两个独立的问题,前者被称为顶层const,后者被称为底层const。

Tips:

  • 只有指针类型既可以是顶层const也可以是底层const,其他类型要么是顶层常量要么是底层常量。

  • 关键字const出现在星号左边表示被指的对象是常量,出现在星号右边表示指针自身是常量,如果同时出现在星号两边表示被指的对象和指针都是常量。

顶层const用于表示任意的对象是常量,包括算数类型、类和指针等,底层const用于表示引用和指针等复合类型的基本类型部分是否是常量。

int i = 10;

int *const p1 = &i;        // 顶层const: 不能改变p1的值
const int *p2 = &i;        // 底层const: 不能通过p2改变i的值
const int *const p3 = &i;  // 底层const + 顶层const

const int &r1 = i;         // 底层const: 不能通过r1改变i的值

以const替换#define

1. 原因

程序编译分为预处理、编译和链接三个阶段。#define是不被视为语言的一部分,在预处理阶段就会进行宏展开替换所有的宏,因此进入第二步编译阶段是如果遇到了编译错误,那么错误信息可能会提到3.14而不是PI,导致错误信息不够明朗。

// 不推荐
#define PI 3.14

// 推荐
const doule Pi = 3.14;

宏定义的记号名称PI在编译器开始处理源码之前就被预处理器移走了,于是记号名称PI有可能没进入记号表(symbol table)内。当你运用此常量但获得一个编译器错误信息时,可能会带来困惑,因为这个错误信息也许会提到3.14而非PI。与此相反,double变量Pi肯定会被编译器看到,自然就会进入记号表内。

此外,使用常量可能比使用#define导致较小量的码,因为预处理器盲目地将宏名称PI替换为3.14可能导致目标码(object code)出现多份3.14,若使用常量绝不会出现这个问题。

2. 特殊情况

当我们以常量替换#define时,需要考虑两种特殊情况。

2.1 特殊情况一:定义常量指针

由于常量定义式通常被放在头文件内(以便被不同的源码文件含入),因此有必要(而不只是指针所指之物)声明为const。

例如要在头文件内定义一个常量的C风格字符串,你必须写const两次:

const char* const str = "foo";

在C++中string对象往往比C风格字符串更合适,因此更好的常量字符串定义方法是:

const std::string str = "foo";

2.2 特殊情况二:class专属常量

为了将常量的作用域(scope)限制于class内,你必须让它成为class的一个成员(member);而为了确保此常量只有一份实体,你必须让它成为一个static成员:

class Foo {
 public:
    static const int IntValue = 1;  // 类内常量声明式获得初始值, 仅支持整数类型
};

通常C++要求你对你所使用的任何东西提供一个定义式,但是如果它是个class专属常量又是static且为整数类型(integral type,例如ints、chars和bools),那么只要不取它们的地址你就可以声明并使用它们而无须提供定义式

上例中的“in-class”初值设定只允许对整数常量进行,对于非整数类型无法编译通过:

class Foo {
 public:
    static const double DoubleValue = 3.14;  // 错误
};

// 编译报错:
test.h:5:39: error: ‘constexpr’ needed for in-class initialization of static data member ‘const double Foo::DoubleValue’ of non-integral type [-fpermissive]
     static const double DoubleValue = 3.14;

正确的写法如下:

// Foo.h
class Foo {
 public:
    static const double DoubleValue;  // 在头文件中声明
};

// Foo.cpp
const double Foo::DoubleValue = 3.14;  // 在实现文件中定义

以enum替换const

1. 取enum值地址是非法的

某种程度上enum的行为相比于const更像#define,有时候这正是我们想要的。例如取一个const的地址是合法的,但是取一个enum值的地址就不合法,而取一个#define的地址通常也不合法。如果你不想让别人获得一个pointer或reference指向你的某个整数常量,enum可以帮助你实现这个约束。

2. enum可以避免非必要的内存分配

优秀的编译器不会为“整数型const对象”设定另外的存储空间(除非你创建一个pointer或reference指向该对象),不够优秀的编译器却可能如此,而这可能是你不想要的。

enum和#define一样绝不会导致非必要的内存分配

const与迭代器

STL迭代器是以指针为依据塑造的,所以迭代器的作用就像一个T*指针。声明迭代器为const就像声明指针为const一样(即声明一个T* const指针),但它指向的对象值是可以改变的。

如果你希望迭代器所指的对象值不可被改变(即希望STL模拟一个const T*指针),你需要的是const_iterator

// 顶层const: T* const
std::vector<int> vi;
const std::vector<int>::iterator iter = vi.begin();
*iter = 10;  // 正确
iter++;      // 错误

// 顶层const: T* const
std::vector<int> vi;
std::vector<int>::const_iterator iter = vi.begin();
*iter = 10;  // 错误
iter++;      // 正确

const与函数声明

const最具威力的用法是面对函数声明时的应用。在一个函数声明中,const可以和函数返回值、参数和函数自身(如果是成员函数)等产生关联。

1. const与函数返回值

令函数返回一个常量值往往可以降低客户端错误代码造成的意外,而又不至于放弃安全性和高效性。举个例子,考虑有理数(rational numbers)的operator*声明:

class Rational { ... };
const Rational operator*(const Rational& lhs, const Rational& rhs);

由于operator*返回const对象,因此客户端无法实现如下的错误代码:

Rational a, b, c;
(a * b) = c;  //  (a * b) 的结果上调用 operator=

正常程序员一般不会对两个数值的乘积再做一次赋值(assignment),但有时候因为打字错误(以及一个可被隐式转换为bool的类型)就很容易写出这种bug:

// 错误写法
if (a * b = c)
    
// 正确写法
if (a * b == c)

2. const与函数参数

除非你有需要改动参数或局部对象,否则请将它们声明为const。只不过多打6个字符就可以避免很多恼人的错误,例如将==意外写成=

3. const成员函数

3.1 使用const成员函数的原因

在类内定义const成员函数,是为了确认该成员函数可作用于const对象。const成员函数之所以重要主要有两个原因:

  • 它们使class接口更容易被理解,可以通过函数声明直到哪个函数可以改动对象内容而哪个函数不行。

  • 它们使“操作const对象”成为可能,这对编写高效代码是个关键,因为改善C++程序效率的一个根本方法是以pass by reference to const方式传递对象,而此技术可能的前提是我们有const成员函数可用来处理取得(并非修饰而成)的const对象。

3.2 const成员函数与重载

**两个成员函数如果只是常量性(constness)不同,可以被重载。**这是一个非常重要的C++特性。

class TextBlock {
 public:
    // operator[] for const对象
    const char& operator[](std::size_t position) const {
        return text_[position];
    }
    // opeartor[] for non-const对象
    char& operator[](std::size_t position) {
        return text_[position];
    }

 private:
    std::string text_;
};

以上面的代码为例,只要重载operator[]并对不同的版本给予不同的返回类型,就可以令const和non-const TextBlock获得不同的处理。

3.3 bitwise constness与logical constness

关于const成员函数有两类说法:

  • bitwise constness(或称physical constness):成员函数只有在不更改对象任何成员变量(static除外)时才可以说是const的,即不更改对象内的任何一个bit。

  • logical constness:一个const成员函数可以修改它所处理的对象内的某些bits,但只有在客户端侦测不出的情况下才得如此。

bitwise constness正是C++对常量性(constness)的定义,因此const成员函数不可以更改对象内任何non-static成员变量。

尽管编译器强制实施bitwise constness,但你编写程序的时候应该尽量使用“概念上的常量”。

不幸的是许多成员函数虽然不十足具备const性质但却能通过bitwise测试。更具体地说,一个更改了“指针所指物”的成员函数虽然不能算是const,但如果只有指针(而非其所指物)隶属于对象,那么称此函数为bitwise const不会引发编译器异议。

举个例子,CTextBlock类返回一个reference指向对象内部值,但是却不适当地将其operator[]声明为const成员函数。不过operator[]实现代码并不更改text_ptr_,因此编译器认为它是bitwise constness并产出目标码。

class CTextBlock {
 public:
    explicit CTextBlock(const char *text) {
        text_ptr_ = new char[std::strlen(text) + 1];
        snprintf(text_ptr_, std::strlen(text) + 1, text);
    }
    ~CTextBlock() {
        delete text_ptr_;
        text_ptr_ = nullptr;
    }
    void print() const {
        std::cout << text_ptr_ << std::endl;
    }
    char& operator[](std::size_t position) const {
        return text_ptr_[position];
    }
 private:
    char* text_ptr_;
};

int main() {
    const CTextBlock foo("Cat");  // 声明一个常量
    foo.print();
    foo[0] = 'D';
    foo[1] = 'o';
    foo[2] = 'g';
    foo.print();

    return 0;
}

// 输出:
$g++ -g test.cpp -o test -std=c++11
$./test 
Cat
Dog
root

在上面的例子中,我们创建了一个常量CTextBlock且调用了它的const成员函数operator[],最终还是修改了它的值。

这种情况导出所谓的logic constness,这一派拥护者主张一个const成员函数可以修改它所处理的对象内的某些bits,但只有在客户端侦测不出的情况下才得如此。例如你的CTextBlock类有可能高速缓存(cache)文本区块的长度以便应付询问。

class CTextBlock {
 public:
    std::size_t length() const {
        if (!length_is_valid_) {
            text_length_ = std::strlen(text_ptr_);  // 编译不通过, 在const成员函数
            length_is_valid_ = true;                // 内不能给成员变量赋值
        }
    }

 private:
    char* text_ptr_;
    std::size_t text_length_;  // 最近一次计算的文本区块长度
    bool length_is_valid_;     // 目前的长度是否有效
};

成员函数length()并不是bitwise const的,因为text_length_length_is_valid_变量都可能被修改。这两个成员变量被修改对const CTextBlock变量而言是可以接受的,但是编译器并不接受(编译器只同意bitwise constness),这该怎么办?

解决方法很简单:利用C++一个与const相关的关键字:mutable(可变的),它可以释放掉non-static变量的bitwise constness约束。

class CTextBlock {
 public:
    std::size_t length() const {
        if (!length_is_valid_) {
            text_length_ = std::strlen(text_ptr_);  // 这些成员变量可能被修改, 即使
            length_is_valid_ = true;                // 在const成员函数内部
        }
    }

 private:
    char* text_ptr_;
    mutable std::size_t text_length_;  // 最近一次计算的文本区块长度
    mutable bool length_is_valid_;     // 目前的长度是否有效
};

3.4 在const和non-const成员函数中避免重复

当const和non-const成员函数有着实质等价的实现时,令non-const版本调用const版本可以避免代码重复。

一般情况下一个成员函数的const和non-const版本内部逻辑基本上是一致的,唯一的不同在于const版本的返回类型多了一个const修饰。所以令non-const operator[]调用其const兄弟是一个避免代码重复的安全做法:

class TextBlock {
 public:
    // operator[] const版本
    const char& operator[](std::size_t position) const {
        return text_[position];
    }
    // opeartor[] non-const版本
    char& operator[](std::size_t position) {
        return const_cast<char&>(                 // 移除operator[]返回值的const
            static_cast<const TextBlock&>(*this)  // 为*this加上const
            [position]);
    }

 private:
    std::string text_;
};

上述代码存在两个转型动作:

  • *thisTextBlock&类型转为const TextBlock&

  • 从const版本的operator[]返回值中移除const

需要注意的是,反向做法(令const版本调用non-const版本)是不合适的。因为const成员函数承诺绝不改变其对象的逻辑状态(logical state),non-const成员函数却没有这个承诺。如果在const函数内调用non-const函数,就是冒了这样的风险:你曾经承诺不改动的对象被改动了。